baubs git
Commit dabfa3b35a00b871daa320822f81ca310abac440
Parents : ea87964
Author : Jan-Henrik Bruhn <jan-henrik.bruhn@offis.de>
Date : 2025-12-25T16:48:47+01:00
feature: Add error badge with auto-opening popover for machine errors
- Replace error button with red error badge in AppHeader
- Add auto-open popover when any error occurs (machine, pairing, or Pyodide)
- Popover auto-closes when errors are cleared
- Respect user dismissals (won't reopen for same error)
- Remove error display from WorkflowStepper (single source of truth)
- Add shortName field to error definitions (max 15 chars)
- Add unit tests to validate error shortName length constraints
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Changes
4 files changed, 200 insertions(+), 59 deletions(-)
Diff
diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
index 516b6ed..0ec0976 100644
--- a/src/components/AppHeader.tsx
+++ b/src/components/AppHeader.tsx
@@ -1,3 +1,4 @@
+import { useState, useEffect, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from "../stores/useUIStore";
@@ -7,6 +8,7 @@ import {
getStateVisualInfo,
getStatusIndicatorState,
} from "../utils/machineStateHelpers";
+import { hasError, getErrorDetails } from "../utils/errorCodeHelpers";
import {
CheckCircleIcon,
BoltIcon,
@@ -58,6 +60,15 @@ export function AppHeader() {
})),
);
+ // State management for error popover auto-open/close
+ const [errorPopoverOpen, setErrorPopoverOpen] = useState(false);
+ const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
+ null,
+ );
+ const prevMachineErrorRef = useRef<number | undefined>(undefined);
+ const prevErrorMessageRef = useRef<string | null>(null);
+ const prevPyodideErrorRef = useRef<string | null>(null);
+
// Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus);
const stateIcons = {
@@ -75,6 +86,66 @@ export function AppHeader() {
? getStatusIndicatorState(machineStatus)
: "idle";
+ // Auto-open/close error popover based on error state changes
+ /* eslint-disable react-hooks/set-state-in-effect */
+ useEffect(() => {
+ const currentError = machineError;
+ const prevError = prevMachineErrorRef.current;
+ const currentErrorMessage = machineErrorMessage;
+ const prevErrorMessage = prevErrorMessageRef.current;
+ const currentPyodideError = pyodideError;
+ const prevPyodideError = prevPyodideErrorRef.current;
+
+ // Check if there's any error now
+ const hasAnyError =
+ machineErrorMessage || pyodideError || hasError(currentError);
+ // Check if there was any error before
+ const hadAnyError =
+ prevErrorMessage || prevPyodideError || hasError(prevError);
+
+ // Auto-open popover when new error appears
+ const isNewMachineError =
+ hasError(currentError) &&
+ currentError !== prevError &&
+ currentError !== dismissedErrorCode;
+ const isNewErrorMessage =
+ currentErrorMessage && currentErrorMessage !== prevErrorMessage;
+ const isNewPyodideError =
+ currentPyodideError && currentPyodideError !== prevPyodideError;
+
+ if (isNewMachineError || isNewErrorMessage || isNewPyodideError) {
+ setErrorPopoverOpen(true);
+ }
+
+ // Auto-close popover when all errors are cleared
+ if (!hasAnyError && hadAnyError) {
+ setErrorPopoverOpen(false);
+ setDismissedErrorCode(null); // Reset dismissed tracking
+ }
+
+ // Update refs for next comparison
+ prevMachineErrorRef.current = currentError;
+ prevErrorMessageRef.current = currentErrorMessage;
+ prevPyodideErrorRef.current = currentPyodideError;
+ }, [machineError, machineErrorMessage, pyodideError, dismissedErrorCode]);
+ /* eslint-enable react-hooks/set-state-in-effect */
+
+ // Handle manual popover dismiss
+ const handlePopoverOpenChange = (open: boolean) => {
+ setErrorPopoverOpen(open);
+
+ // If user manually closes it, remember the current error state to prevent reopening
+ if (!open) {
+ // For machine errors, track the error code
+ if (hasError(machineError)) {
+ setDismissedErrorCode(machineError);
+ }
+ // Update refs so we don't reopen for the same error message/pyodide error
+ prevErrorMessageRef.current = machineErrorMessage;
+ prevPyodideErrorRef.current = pyodideError;
+ }
+ };
+
return (
<TooltipProvider>
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
@@ -166,22 +237,22 @@ export function AppHeader() {
)}
{/* Error indicator - always render to prevent layout shift */}
- <Popover>
+ <Popover
+ open={errorPopoverOpen}
+ onOpenChange={handlePopoverOpenChange}
+ >
<PopoverTrigger asChild>
- <Button
- size="sm"
- variant="destructive"
+ <button
className={cn(
- "gap-1.5 flex-shrink-0",
+ "inline-flex items-center rounded-full border border-transparent bg-destructive text-white px-2.5 py-1.5 text-xs font-semibold gap-1.5 cursor-pointer hover:bg-destructive/90 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2",
machineErrorMessage || pyodideError
? "animate-pulse hover:animate-none"
: "invisible pointer-events-none",
)}
aria-label="View error details"
- disabled={!(machineErrorMessage || pyodideError)}
>
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
- <span>
+ <span className="font-semibold">
{(() => {
if (pyodideError) return "Python Error";
if (isPairingError) return "Pairing Required";
@@ -202,17 +273,19 @@ export function AppHeader() {
return "Pattern Error";
}
if (machineError !== undefined) {
- return `Machine Error`;
+ // Get short name from error details
+ const errorDetails = getErrorDetails(machineError);
+ return errorDetails?.shortName || "Machine Error";
}
// Default fallback
return "Error";
})()}
</span>
- </Button>
+ </button>
</PopoverTrigger>
- {/* Error popover content */}
+ {/* Error popover content - unchanged */}
{(machineErrorMessage || pyodideError) && (
<ErrorPopoverContent
machineError={
diff --git a/src/components/WorkflowStepper.tsx b/src/components/WorkflowStepper.tsx
index 150c0ec..853445c 100644
--- a/src/components/WorkflowStepper.tsx
+++ b/src/components/WorkflowStepper.tsx
@@ -8,7 +8,6 @@ import {
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../types/machine";
-import { getErrorDetails, hasError } from "../utils/errorCodeHelpers";
interface Step {
id: number;
@@ -28,38 +27,7 @@ const steps: Step[] = [
];
// Helper function to get guide content for a step
-function getGuideContent(
- stepId: number,
- machineStatus: MachineStatus,
- hasError: boolean,
- errorCode?: number,
- errorMessage?: string,
-) {
- // Check for errors first
- if (hasError) {
- const errorDetails = getErrorDetails(errorCode);
-
- if (errorDetails?.isInformational) {
- return {
- type: "info" as const,
- title: errorDetails.title,
- description: errorDetails.description,
- items: errorDetails.solutions || [],
- };
- }
-
- return {
- type: "error" as const,
- title: errorDetails?.title || "Error Occurred",
- description:
- errorDetails?.description ||
- errorMessage ||
- "An error occurred. Please check the machine and try again.",
- items: errorDetails?.solutions || [],
- errorCode,
- };
- }
-
+function getGuideContent(stepId: number, machineStatus: MachineStatus) {
// Return content based on step
switch (stepId) {
case 1:
@@ -273,17 +241,10 @@ function getCurrentStep(
export function WorkflowStepper() {
// Machine store
- const {
- machineStatus,
- isConnected,
- machineError,
- error: errorMessage,
- } = useMachineStore(
+ const { machineStatus, isConnected } = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
isConnected: state.isConnected,
- machineError: state.machineError,
- error: state.error,
})),
);
@@ -297,7 +258,6 @@ export function WorkflowStepper() {
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const hasPattern = pesData !== null;
- const hasErrorFlag = hasError(machineError);
const currentStep = getCurrentStep(
machineStatus,
isConnected,
@@ -443,13 +403,7 @@ export function WorkflowStepper() {
aria-label="Step guidance"
>
{(() => {
- const content = getGuideContent(
- popoverStep,
- machineStatus,
- hasErrorFlag,
- machineError,
- errorMessage || undefined,
- );
+ const content = getGuideContent(popoverStep, machineStatus);
if (!content) return null;
const colorClasses = {
diff --git a/src/utils/errorCodeHelpers.test.ts b/src/utils/errorCodeHelpers.test.ts
new file mode 100644
index 0000000..1699fc6
--- /dev/null
+++ b/src/utils/errorCodeHelpers.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect } from "vitest";
+import { getErrorDetails, SewingMachineError } from "./errorCodeHelpers";
+
+describe("errorCodeHelpers", () => {
+ describe("shortName validation", () => {
+ it("should ensure all error shortNames are 15 characters or less", () => {
+ // Get all error codes except None (0xDD) and Unknown (0xEE) since they might not have details
+ const errorCodes = Object.values(SewingMachineError).filter(
+ (code) =>
+ code !== SewingMachineError.None &&
+ code !== SewingMachineError.Unknown &&
+ code !== SewingMachineError.OtherError,
+ );
+
+ const violations: Array<{
+ code: number;
+ shortName: string;
+ length: number;
+ }> = [];
+
+ errorCodes.forEach((code) => {
+ const details = getErrorDetails(code);
+ if (details?.shortName) {
+ const length = details.shortName.length;
+ if (length > 15) {
+ violations.push({
+ code,
+ shortName: details.shortName,
+ length,
+ });
+ }
+ }
+ });
+
+ // If there are violations, create a helpful error message
+ if (violations.length > 0) {
+ const violationMessages = violations
+ .map(
+ (v) =>
+ `Error code 0x${v.code.toString(16).toUpperCase()}: "${v.shortName}" (${v.length} chars)`,
+ )
+ .join("\n ");
+
+ expect.fail(
+ `The following error shortNames exceed 15 characters:\n ${violationMessages}`,
+ );
+ }
+
+ // Assertion to confirm test ran
+ expect(violations).toHaveLength(0);
+ });
+
+ it("should ensure all error details have a shortName", () => {
+ // Get all error codes except None (0xDD) and Unknown (0xEE)
+ const errorCodes = Object.values(SewingMachineError).filter(
+ (code) =>
+ code !== SewingMachineError.None &&
+ code !== SewingMachineError.Unknown &&
+ code !== SewingMachineError.OtherError,
+ );
+
+ const missing: number[] = [];
+
+ errorCodes.forEach((code) => {
+ const details = getErrorDetails(code);
+ if (details && !details.shortName) {
+ missing.push(code);
+ }
+ });
+
+ if (missing.length > 0) {
+ const missingCodes = missing
+ .map((code) => `0x${code.toString(16).toUpperCase()}`)
+ .join(", ");
+
+ expect.fail(
+ `The following error codes are missing shortName: ${missingCodes}`,
+ );
+ }
+
+ expect(missing).toHaveLength(0);
+ });
+ });
+});
diff --git a/src/utils/errorCodeHelpers.ts b/src/utils/errorCodeHelpers.ts
index a0d3e0d..5c13548 100644
--- a/src/utils/errorCodeHelpers.ts
+++ b/src/utils/errorCodeHelpers.ts
@@ -42,6 +42,8 @@ export const SewingMachineError = {
*/
interface ErrorInfo {
title: string;
+ /** Short name for badge display (max 15 characters) */
+ shortName: string;
description: string;
solutions: string[];
/** If true, this "error" is really just an informational step, not a real error */
@@ -55,12 +57,14 @@ interface ErrorInfo {
const ERROR_DETAILS: Record<number, ErrorInfo> = {
[SewingMachineError.NeedlePositionError]: {
title: "The Needle is Down",
+ shortName: "Needle Down",
description:
"The needle is in the down position and needs to be raised before continuing.",
solutions: ["Press the needle position switch to raise the needle"],
},
[SewingMachineError.SafetyError]: {
title: "Safety Error",
+ shortName: "Safety Error",
description: "The machine is sensing an operational issue.",
solutions: [
"Remove the thread on the top of the fabric and then remove the needle",
@@ -72,21 +76,25 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.LowerThreadSafetyError]: {
title: "Lower Thread Safety Error",
+ shortName: "Lower Thread",
description: "The bobbin winder safety device is activated.",
solutions: ["Check if the thread is tangled"],
},
[SewingMachineError.LowerThreadFreeError]: {
title: "Lower Thread Free Error",
+ shortName: "Lower Thread",
description: "Problem with lower thread.",
solutions: ["Slide the bobbin winder shaft toward the front"],
},
[SewingMachineError.RestartError10]: {
title: "Restart Required",
+ shortName: "Restart Needed",
description: "A malfunction occurred.",
solutions: ["Turn the machine off, then on again"],
},
[SewingMachineError.RestartError11]: {
title: "Restart Required (M519411)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519411",
solutions: [
"Turn the machine off, then on again",
@@ -95,6 +103,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError12]: {
title: "Restart Required (M519412)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519412",
solutions: [
"Turn the machine off, then on again",
@@ -103,6 +112,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError13]: {
title: "Restart Required (M519413)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519413",
solutions: [
"Turn the machine off, then on again",
@@ -111,6 +121,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError14]: {
title: "Restart Required (M519414)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519414",
solutions: [
"Turn the machine off, then on again",
@@ -119,6 +130,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError15]: {
title: "Restart Required (M519415)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519415",
solutions: [
"Turn the machine off, then on again",
@@ -127,6 +139,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError16]: {
title: "Restart Required (M519416)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519416",
solutions: [
"Turn the machine off, then on again",
@@ -135,6 +148,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError17]: {
title: "Restart Required (M519417)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519417",
solutions: [
"Turn the machine off, then on again",
@@ -143,6 +157,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError18]: {
title: "Restart Required (M519418)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519418",
solutions: [
"Turn the machine off, then on again",
@@ -151,6 +166,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError19]: {
title: "Restart Required (M519419)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519419",
solutions: [
"Turn the machine off, then on again",
@@ -159,6 +175,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError1A]: {
title: "Restart Required (M51941A)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M51941A",
solutions: [
"Turn the machine off, then on again",
@@ -167,6 +184,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError1B]: {
title: "Restart Required (M51941B)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M51941B",
solutions: [
"Turn the machine off, then on again",
@@ -175,6 +193,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError1C]: {
title: "Restart Required (M51941C)",
+ shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M51941C",
solutions: [
"Turn the machine off, then on again",
@@ -183,6 +202,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.NeedlePlateError]: {
title: "Needle Plate Error",
+ shortName: "Needle Plate",
description: "Check the needle plate cover.",
solutions: [
"Reattach the needle plate cover",
@@ -191,11 +211,13 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.ThreadLeverError]: {
title: "Thread Lever Error",
+ shortName: "Thread Lever",
description: "The needle threading lever is not in its original position.",
solutions: ["Return the needle threading lever to its original position"],
},
[SewingMachineError.UpperThreadError]: {
title: "Upper Thread Error",
+ shortName: "Upper Thread",
description: "Check and rethread the upper thread.",
solutions: [
"Check the upper thread and rethread it",
@@ -204,6 +226,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.LowerThreadError]: {
title: "Lower Thread Error",
+ shortName: "Lower Thread",
description: "The bobbin thread is almost empty.",
solutions: [
"Replace the bobbin thread",
@@ -212,6 +235,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.UpperThreadSewingStartError]: {
title: "Upper Thread Error at Sewing Start",
+ shortName: "Upper Thread",
description: "Check and rethread the upper thread.",
solutions: [
"Press the Accept button to resolve the error",
@@ -221,21 +245,25 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.PRWiperError]: {
title: "PR Wiper Error",
+ shortName: "PR Wiper",
description: "PR Wiper Error.",
solutions: ["Press the Accept button to resolve the error"],
},
[SewingMachineError.HoopError]: {
title: "Hoop Error",
+ shortName: "Hoop Error",
description: "This embroidery frame cannot be used.",
solutions: ["Use another frame that fits the pattern"],
},
[SewingMachineError.NoHoopError]: {
title: "No Hoop Detected",
+ shortName: "No Hoop",
description: "No hoop attached.",
solutions: ["Attach the embroidery hoop"],
},
[SewingMachineError.InitialHoopError]: {
title: "Machine Initialization Required",
+ shortName: "Init Required",
description: "An initial homing procedure must be performed.",
solutions: [
"Remove the embroidery hoop from the machine completely",
@@ -248,12 +276,14 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RegularInspectionError]: {
title: "Regular Inspection Required",
+ shortName: "Inspection Due",
description:
"Preventive maintenance is recommended. This message is displayed when maintenance is due.",
solutions: ["Please contact the service center"],
},
[SewingMachineError.Setting]: {
title: "Settings Error",
+ shortName: "Settings Error",
description: "Stitch count cannot be changed.",
solutions: ["This setting cannot be modified at this time"],
},
Served by rngit 1.3.3 - Generated in 0.08s